[SPARK-59185][SQL] Derive a StartsWith prefix filter from leading-literal LIKE patterns - #58484
Conversation
d399e01 to
a09a8a2
Compare
|
@uros-b You've been so kind with your time. Thanks. Would you mind taking a look at this one too? |
uros-b
left a comment
There was a problem hiding this comment.
Thank you @david-mollitor-db for the PR! cc @stevomitric with further review here
| simplifyLike(input, pattern.toString, escapeChar).getOrElse(l) | ||
| val patternStr = pattern.toString | ||
| simplifyLike(input, patternStr, escapeChar) | ||
| .orElse(derivePrefixStartsWith(input, patternStr, escapeChar, l)) |
There was a problem hiding this comment.
This duplicates input (StartsWith(input, prefix) && Like(input, ...)). LikeAll already refuses that unless CollapseProject.isCheap(child) (SPARK-40228). Please gate this derive path the same way — otherwise rand() LIKE 'a%b%' evaluates two different Rand values, and expensive kids (e.g. sha2) run twice. 'a%' stays single-eval today; this extends duplication to 'a%b%', 'a_b%', etc. A sibling of the existing SPARK-40228 cheap-child test would lock it in.
There was a problem hiding this comment.
@stevomitric Updated. I have also identified another code path that lacked this gate: #58663
a09a8a2 to
d8bcfcc
Compare
| if (!binaryCollation || !CollapseProject.isCheap(input) || pattern.contains(escapeChar) || | ||
| like.containsTag(LIKE_PREFIX_GUARDED)) { | ||
| None | ||
| } else { | ||
| val prefix = pattern.takeWhile(c => c != '%' && c != '_') | ||
| if (prefix.isEmpty || prefix.length == pattern.length) { |
There was a problem hiding this comment.
this bails whenever the escape char appears anywhere in the pattern, but the leading literal can be escape-free while the escape only appears later, e.g. 'ab%c%d%', whose prefix ab is clean.
we could have something like:
if (!binaryCollation || like.containsTag(LIKE_PREFIX_GUARDED)) {
None
} else {
val prefix = pattern.takeWhile(c => c != '%' && c != '_')
if (prefix.isEmpty || prefix.length == pattern.length ||
prefix.contains(escapeChar)) {…eral LIKE patterns
`LikeSimplification` rewrites simple `LIKE` patterns into cheaper predicates
(`'A%'` -> `StartsWith`, `'%B'` -> `EndsWith`, `'A%B'` -> length guard + `StartsWith` +
`EndsWith`, `'%B%'` -> `Contains`, exact -> `EqualTo`). Multi-wildcard patterns with a
leading literal that match none of those shapes -- e.g. `'A%B%'`, `'AB%CD%EF'`, `'A_B%'`
-- fall through unchanged and stay a full regex `Like`, so the data source receives no
predicate and the per-row regex runs on every row.
This derives the leading literal `A` as the necessary condition `StartsWith(col, A)` and
keeps the original `LIKE` as the exact residual:
col LIKE 'A%B%' ==> StartsWith(col, A) && (col LIKE 'A%B%')
`StartsWith` is placed first so the cheap check short-circuits the regex, and it is a
predicate the existing pushdown path understands: on UTF8_BINARY it translates to
`StringStartsWith`, which prunes Parquet row groups via min/max. Results are unchanged --
`StartsWith(A)` is implied by `LIKE 'A%...'` and the exact `LIKE` is retained as the residual.
The derivation is restricted to binary-equality collations (`supportsBinaryEquality`):
under a collation-aware collation the `Like` regex match (Java regex case flags) and
`StartsWith` (`CollationSupport`) can disagree, so `StartsWith(A)` would not be a sound
necessary condition; and `StringStartsWith` only pushes down for UTF8_BINARY. Only
`StartsWith` is derived from the leading literal -- Parquet's `StringEndsWith` and
`StringContains` do not prune -- and the `LikeAll`/`LikeAny` paths are unchanged. A
`TreeNodeTag` on the residual `Like` keeps the rule idempotent under the fixed-point batch.
This also updates the Python data source filter-pushdown test and the
`DataSource.pushFilters` docstring, which previously documented such patterns as pushing
no filters.
Generated-by: Claude Opus 4.8
d8bcfcc to
ac56b5c
Compare
What changes were proposed in this pull request?
LikeSimplificationrewrites simpleLIKEpatterns into cheaper predicates ('A%'->StartsWith,'%B'->EndsWith,'A%B'-> length guard +StartsWith+EndsWith,'%B%'->Contains, exact string ->EqualTo). Multi-wildcard patterns that have aleading literal but match none of those shapes -- e.g.
'A%B%','AB%CD%EF','A_B%'--fall through unchanged and remain a full regex
Like, so the data source receives nopredicate and the per-row regex runs on every row.
This PR makes
LikeSimplificationadditionally derive the leading literalAas thenecessary condition
StartsWith(col, A), keeping the originalLIKEas the exact residual:StartsWithis placed first so the cheap check short-circuits the regex. The derivation isrestricted to binary-equality collations (
StringType.supportsBinaryEquality, i.e.UTF8_BINARY) and aTreeNodeTagon the residualLikekeeps the rule idempotent under thefixed-point optimizer batch. Only
StartsWithis derived from the leading literal; theLikeAll/LikeAnypaths are unchanged.Why are the changes needed?
UTF8_BINARY,StartsWithtranslates tosources.StringStartsWith, which prunes Parquet row groups via min/max statistics. Readerscannot prune on the raw
Like, so today a leading-literal multi-wildcardLIKEreads everyrow group.
StartsWithshort-circuits the more expensive regex onrows that fail the prefix.
StartsWith(A)is implied byLIKE 'A%...', and the exactLIKEis retained as the residual, so the conjunction accepts exactly the same rows. This mirrors
how PostgreSQL, SQL Server and SQLite turn a leading-literal
LIKEinto a sargable prefixpredicate plus a residual recheck.
The derivation is gated on binary equality for correctness as well as benefit: under a
collation-aware collation (e.g.
UTF8_LCASE) theLikeregex match (Java regex case flags)and
StartsWith(CollationSupport) can disagree, soStartsWith(A)would not be a soundnecessary condition; and
StringStartsWithis pushed down only forUTF8_BINARY(non-binaryis wrapped as
CollatedStringStartsWith, which readers ignore).EndsWith/Containsare notderived from trailing/inner literals because Parquet's
StringEndsWith/StringContainshavecanDrop = false(no pruning).Does this PR introduce any user-facing change?
No. Query results are identical; this is a performance improvement (added pushdown and a
short-circuit conjunct on an otherwise unsimplified
LIKE).How was this patch tested?
LikeSimplificationSuitecovering: derivation for'a%b%', a multi-charprefix with multiple wildcards,
'_'patterns, no derivation when there is no leading literal,no derivation when the pattern contains the escape char, no derivation for a non-binary
(
UTF8_LCASE) collation, and idempotency.ParquetFilterSuiteverifying thatLIKE 'ab%cd%'/'ab%cd%ef'/'a_b%'push aStringStartsWithand prune Parquet row groups.test_python_datasourceand its Connectparity) and the
DataSource.pushFiltersdocstring to reflect that a leading-literalmulti-wildcard
LIKEnow pushes aStringStartsWithprefix filter.build/sbt 'catalyst/testOnly *LikeSimplificationSuite'andbuild/sbt 'sql/testOnly *ParquetV1FilterSuite -- -z "leading-literal"'pass; scalastyle clean.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.8